#!/usr/bin/tclsh
#
# i8kmon -- Monitor the temperature on Dell laptops.
#
# Copyright (C) 2023        Armin Wolf <W_Armin@gmx.de>
# Copyright (C) 2013-2017   Vitor Augusto <vitorafsr@gmail.com>
# Copyright (C) 2001-2005   Massimo Dal Zotto <dz@debian.org>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.

# Necessary until we fully migrate to Tcl 9.0
# package require Tcl 8.6
package require cmdline 1.5
package require coroutine
package require defer
package require fileutil
package require generator
package require logger
package require struct::queue

package require i8k::thermal
package require i8k::hwmon

set onAcPower 0
set error ""

proc setFans {fans fanStates log} {
    set fanNumber 0

    foreach fanState $fanStates {
        incr fanNumber

        if {$fanState == "-"} {
            continue
        }

        if {[dict exists $fans $fanNumber]} {
            set fan [dict get $fans $fanNumber]

            try {
                $fan setState $fanState
            } trap {FAN {invalid state}} {} {
                set maxState [$fan getMaxState]

                ${log}::notice "fan $fanNumber does not support fan state $fanState, using max. fan state $maxState"
                $fan setState $maxState
            }
        } else {
            ${log}::info "fan $fanNumber not found"
        }
    }

    return
}

proc fanControl {config fans temp log} {
    global onAcPower

    # Force initial fan speed update when ondemand is set
    set previousState -1
    set currentState 0

    # Queue used for smoothing the temperature values
    set history [struct::queue]
    defer::defer ${history} delete

    # Initialize the temperature history with 0
    for {set i 0} {$i < [dict get $config average]} {incr i} {
        $history put 0
    }

    while {1} {
        set index [expr {$onAcPower ? 1 : 3}]

        try {
            set temperature [expr {[$temp getTemperature] / 1000}]

            ${log}::debug "device temperature: $temperature °C"

            # Smooth the temperature by averaging the last n temperature samples
            $history get
            $history put $temperature

            set sum [::tcl::mathop::+ {*}[$history peek [$history size]]]
            set temperature [expr {$sum / [$history size]}]

            ${log}::debug "averaged temperature to $temperature °C"

            for {} {$currentState < [expr {[dict get $config num_configs] - 1}]} {incr currentState} {
                set temperatureHigh [lindex [dict get $config $currentState] [expr $index + 1]]

                if {$temperature < $temperatureHigh} {
                    break
                }

                ${log}::debug "temperature above state $currentState threshold ($temperatureHigh °C)"
            }

            for {} {$currentState > 0} {incr currentState -1} {
                set temperatureLow [lindex [dict get $config $currentState] $index]

                if {$temperature > $temperatureLow} {
                    break
                }

                ${log}::debug "temperature below state $currentState threshold ($temperatureLow °C)"
            }

            # When ondemand is set, fan speed updates only happen at state change
            if {$currentState == $previousState && [dict get $config ondemand] > 0} {
                ${log}::debug "skipping fan speed update for state $currentState"
            } else {
                try {
                    setFans $fans [lindex [dict get $config $currentState] 0] $log
                    set previousState $currentState
                    ${log}::debug "switched to state $currentState"
                } trap {POSIX} {msg} {
                    ${log}::warn "unable to update fan speed: $msg"
                    # Force fan speed update on next iteration
                    set previousState -1
                }
            }
        } trap {POSIX} {msg} {
            ${log}::warn "unable to read temperature: $msg"
        }

        coroutine::util after [expr {[dict get $config timeout] * 1000}]
    }

    return
}

proc updatePowerStatus {log} {
    global onAcPower

    while {1} {
        if {[catch {exec "acpi" "-a"} result]} {
            # Assume AC power when execution fails
            ${log}::info "power status: assuming ac power"
            set onAcPower 1
        } else {
            if {[string match "" $result]} {
                # The command might succeed without printing anything
                ${log}::info "power status: assuming ac power"
                set onAcPower 1
            } elseif {[string match *on-line* $result]} {
                ${log}::info "power status: running on ac power ($result)"
                set onAcPower 1
            } else {
                ${log}::info "power status: running on battery power ($result)"
                set onAcPower 0
            }
        }

        # Read AC status once per minute
        coroutine::util after 60000
    }

    return
}

# Gets called by tcl when a coroutine dies
proc bgerror {msg} {
    global error

    set error $msg

    return
}

proc readConfig {path} {
    array set config {
        verbose     0
        ondemand    0
        timeout     2
        average     1
        chip_name   "dell_smm"
        temp_sensor 1
        num_configs 5
        0           {{0 0}  -1  50  -1  50}
        1           {{0 1}  45  55  45  55}
        2           {{1 1}  50  65  50  65}
        3           {{1 2}  60  70  60  70}
        4           {{2 2}  65 128  65 128}
    }

    if {[file exists $path]} {
        try {
            set configContent [fileutil::cat $path]
            set defaultConfig [array get config]
            set safeInterp [interp create -safe]

            defer::defer interp delete $safeInterp

            $safeInterp eval array set config \{ {*}$defaultConfig \}
            $safeInterp eval $configContent

            return [$safeInterp eval {array get config}]
        } trap {} {msg} {
            throw {I8KMON ERROR} "unable to read config: $msg"
        }
    }

    return [array get config]
}

proc parseOptions {configName args} {
    upvar $configName config

    set timeout [list "t.arg" [dict get $config timeout] "Interval for checking hardware status in seconds"]
    set ondemand [list "o" "Only update fan speed when thresholds where exceeded"]
    set verbose [list "v" "Report verbose information"]
    set options [list $timeout $ondemand $verbose]

    array set params [cmdline::getoptions args $options]

    if {$params(o)} {
        dict set config ondemand 1
    }

    if {$params(v)} {
        dict set config verbose 1
    }

    dict set config timeout $params(t)

    return
}

proc validateConfig {configName log} {
    upvar $configName config

    if {[dict get $config verbose] > 0} {
        ${log}::setlevel debug
    }

    if {[dict get $config average] < 1} {
        throw {I8KMON ERROR} "number of temperature samples used for averaging is invalid"
    }

    if {[dict get $config temp_sensor] < 1} {
        throw {I8KMON ERROR} "temperature sensor index is invalid"
    }

    for {set index 0}  {$index < [dict get $config num_configs]} {incr index} {
        lassign [dict get $config $index] fanStates lowAc highAc lowBat highBat

	    # check that for each key high temp > low temp
	    if {$lowAc > $highAc} {
            ${log}::notice "config entry $index: high temperature $highAc °C is lower or equals low temperature $lowAc °C (ac)"
            set highAc [expr {$lowAc + 5}]
        }
	    if {$lowBat > $highBat} {
            ${log}::notice "config entry $index: high temperature $highBat °C is lower or equals low temperature $lowBat °C (battery)"
            set highBat [expr {$lowBat + 5}]
        }

        # The fan state order is reversed (..., fan 3, fan 2, fan 1) due
        # to backwards compatibility
        dict set config $index [list [lreverse $fanStates] $lowAc $highAc $lowBat $highBat]

        ${log}::debug "config entry $index: battery = $lowBat °C - $highBat °C, ac = $lowAc °C - $highAc °C"
    }
}

proc main {args path} {
    global error

    set config [dict create {*}[readConfig $path]]
    set uncontrollableFans 0
    set fans [dict create]
    set log [logger::init i8kmon]

    defer::defer ${log}::delete
    ${log}::setlevel notice

    parseOptions config {*}$args
    validateConfig config $log

    try {
        set hwmonChip [hwmon::detectChip [dict get $config chip_name]]
        set temp [hwmon::getSensor $hwmonChip [dict get $config temp_sensor]]
    } trap {} {msg} {
        throw {I8KMON ERROR} "temperature sensor not found: $msg"
    }

    defer::defer $temp destroy

    generator foreach fan [thermal::detectFans] {
        if {![$fan isSetable]} {
            $fan destroy
            incr uncontrollableFans

            continue
        }

        defer::with {fan} {
            $fan destroy
        }

        dict set fans [$fan getIndex] $fan
    }

    if {[dict size $fans] == 0} {
        if {$uncontrollableFans == 0} {
            throw {I8KMON ERROR} "no fans detected"
        } else {
            throw {I8KMON ERROR} "no controllable fans detected, likely due to missing permissions"
        }
    }

    # Start updatePowerStatus first to ensure onAcStatus is valid
    coroutine::util create updatePowerStatus $log
    coroutine::util create fanControl $config $fans $temp $log

    vwait error

    throw {I8KMON ERROR} "background error: $error"
}

if {$tcl_interactive == 0} {
    try {
        main $argv "/etc/i8kmon.conf"
    } trap {I8KMON ERROR} {msg} {
        puts stderr "i8kmon: $msg"
        exit 1
    } trap {CMDLINE USAGE} {msg} {
        puts $msg
        exit 1
    }
}
